Skip to content

Fix admin permission mismatch for design system deletion - #4695

Merged
liamdebeasi merged 11 commits into
mainfrom
ai_main_fad3bc5b90f940509045
Sep 14, 2026
Merged

liamdebeasi merged 11 commits into
mainfrom
ai_main_fad3bc5b90f940509045

Conversation

@liamdebeasi

@liamdebeasi liamdebeasi commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Admins couldn't delete design systems despite other applications (such as slides) supporting admins deleting design systems.

Also fixed a related bug with slides


Edit in Builder  Preview


To clone this PR locally use the Github CLI with command gh pr checkout 4695

You can tag me at @BuilderIO for anything you want me to fix or change

@builder-io-integration builder-io-integration Bot changed the title Update from the Builder.io agent Fix admin permission mismatch for design system deletion Sep 10, 2026
builder-io-integration[bot]

This comment was marked as outdated.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@liamdebeasi
liamdebeasi requested review from a team and teleaziz and removed request for a team September 10, 2026 14:59
builder-io-integration[bot]

This comment was marked as outdated.

@liamdebeasi
liamdebeasi enabled auto-merge (squash) September 10, 2026 16:30

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 3 potential issues 🔴

Review Details

Code Review Summary

This incremental review rechecked PR #4695 at the latest head. The PR centralizes the admin-level management threshold for design systems, updates the list action to use the same rule, and adds coverage for admin/viewer authorization plus legacy Slides data rendering. The existing comments about saved-template references and wrong-type token values remain unresolved and were not reposted. This remains high risk because it changes authorization and destructive mutation behavior.

New Findings

  • 🔴 High: Shared-admin deletion can clear designSystemId and update timestamps on every linked design, including designs the admin cannot edit. Access to a design system does not grant write access to all consumer designs.
  • 🔴 High: Authorization is checked before the deletion transaction, so a share revoked after assertAccess but before the mutation can still result in deletion by a now-revoked admin.
  • 🟡 Medium: Deleting another owner's default system does not promote a replacement, leaving that owner without an effective default in the Design app.

The role centralization itself is sound, and the delete/share cleanup remains transactional. The Slides implementation provides a useful comparison by handling default promotion and reporting inaccessible linked records rather than silently rewriting them.

🧪 Browser testing: Will run after this review (PR touches UI code)

Comment thread templates/design/actions/delete-design-system.ts Outdated
}),
run: async ({ id }) => {
await assertAccess("design-system", id, "owner");
await assertAccess("design-system", id, DESIGN_SYSTEM_MANAGE_ROLE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Revoked administrators can still complete a deletion

assertAccess runs before the deletion transaction. If an owner revokes the admin share after this check but before the transaction performs its updates/deletes, the now-revoked admin can still delete the system and unlink designs. Revalidate/lock the resource and access grant inside the mutation transaction or otherwise serialize share revocation with destructive deletion.

Additional Info
New issue identified in the latest incremental review.

Fix in Builder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respectfully disagree — not fixing this one, and it's not something introduced by this PR. assertAccess then act (no re-check inside the transaction) is the universal, documented pattern in this codebase: the sharing skill's own example is exactly assertAccess(...); // ...proceed, and I checked — all ~317 actions across every template that call assertAccess" use this same single-check-then-mutate shape, including ones that write inside a db.transaction(e.g.templates/assets/actions/duplicate-library.ts). The sibling Slides delete-design-system.ts this PR was calibrated against has the identical shape (assertAccessthendb.transaction`, no re-validation). Closing this race would mean wrapping every write action in the framework with a lock/re-check — a real architectural change, but far outside the scope of 'fix the admin/UI permission mismatch,' and inconsistent to apply to just this one action while leaving the other 300+ as-is. Happy to raise it as a framework-level follow-up if you'd like, but I'm not making an isolated change here that the rest of the codebase doesn't follow.

Comment thread templates/design/actions/delete-design-system.ts Outdated
@liamdebeasi

Copy link
Copy Markdown
Contributor Author

@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why

@builder-io-integration

Copy link
Copy Markdown
Contributor

Summary

Reviewed all 5 PR feedback items skeptically, verified each against actual code, and replied on every thread:

Fixed (4):

  1. 🟡 Dangling designTemplates.designSystemId — confirmed the column exists and was never cleared. Now unlinked for templates the caller can admin, with templatesSkippedForAccess reported for the rest.
  2. 🟡 Malformed token crash in Slides — confirmed mergeDesignSystemData let wrong-typed values (e.g. headingFont: {}) through, which DesignSystemCard's .split() would crash on. Hardened the shared mergeWithDefaults to fall back to defaults on type mismatch, not just undefined/null — this protects every consumer of that helper, not just the list page.
  3. 🔴 Shared admin rewriting inaccessible designs — confirmed the unconditional update(designs) bypassed the "editor" access check every other design-mutating action enforces. Now checks per-design access before unlinking, mirroring Slides' existing pattern, with skipped designs reported back.
  4. 🟡 No default promotion on delete — confirmed the Design app's action never promoted a replacement default (unlike Slides). Ported that logic over.

Disagreed (1):

  • 🔴 TOCTOU on revoked admin — verified this is the codebase-wide, documented assertAccess-then-mutate convention used by all ~317 actions (including the Slides sibling this PR was modeled on). Fixing it in isolation here would be inconsistent and is a framework-level architectural question, not a regression from this PR. Explained the reasoning in the reply.

All changes verified with new/updated regression tests (each confirmed to fail without the fix), full typecheck (clean except one pre-existing unrelated error), and all 71 repo guards passing.



🟢 Addressed 4 of 5 PR review findings with fixes + tests, pushed back on 1 with reasoning — replied on all 5 comment threads, guards and tests green.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 4 potential issues 🔴

Review Details

Code Review Summary

This incremental review covers the latest expansion of the design-system delete flow. The PR now attempts to clean up linked designs and saved templates with per-resource access checks, promotes a replacement default, and normalizes wrong-typed Slides token values. Four previously reported issues were verified fixed and their stale threads were resolved: template cleanup, malformed token rendering, unauthorized cross-design rewrites, and missing default promotion. The revocation race remains unresolved and was not reposted.

New Findings

  • 🔴 High: Default promotion uses the deleter’s active organization instead of the deleted system’s stored organization, which can promote an unrelated system in a cross-org shared-admin scenario.
  • 🟡 Medium: Cleanup updates are not compare-and-set scoped to the deleted system, so a concurrently reassigned design/template can have its new link cleared.
  • 🟡 Medium: Rejected resolveAccess or unlink updates are swallowed by Promise.allSettled; the action returns success without reporting failed cleanup.
  • 🟡 Medium: Deletion performs unbounded per-resource access lookups, creating an N+1/full-row query pattern for heavily reused systems.

The access-aware cleanup direction is an improvement, but the post-transaction asynchronous cleanup needs stronger failure signaling and race-safe predicates before this destructive action is reliable.

🧪 Browser testing: Will run after this review (PR touches UI code)

.delete(schema.designSystems)
.where(eq(schema.designSystems.id, id));

if (access.resource.isDefault) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Promote defaults in the deleted system's scope, not the deleter's active org

The replacement query uses getRequestOrgId() rather than access.resource.orgId. A shared admin can be active in org B while deleting a system owned in org A; this can leave org A without a replacement while promoting an unrelated system in B that the caller cannot access. Scope the replacement using the deleted resource's stored orgId, including its null/personal branch.

Additional Info
New issue identified by 1/3 review agents; high severity retained per review policy.

Fix in Builder

if (!access || !canEditDesignRole(access.role)) {
return { id: designId, status: "skipped-no-access" };
}
await getDb()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Do not clear a link that was reassigned during cleanup

The linked IDs are captured before the delete transaction, then cleanup runs later on separate database operations and updates by resource ID only. If a design or template is reassigned to another design system in that window, this action still sets its designSystemId to null and destroys the newer link. Include designSystemId = id in the update predicate and treat a zero-row compare-and-set as a stale assignment.

Additional Info
Identified independently by 3/3 code-review agents; severity normalized to medium by majority vote.

Fix in Builder

// template we can't touch (missing access) is left dangling rather than
// retried here — both get-design-template and list-design-templates
// already mask a dangling designSystemId by resolving it back to null.
const [designResults, templateResults] = await Promise.all([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Surface unlink failures instead of treating deletion as complete

Promise.allSettled captures rejected access checks and unlink updates, but the response only reports fulfilled skipped-no-access values. A rejected cleanup leaves a linked resource dangling while the action returns deleted: true with no retryable failure information. Preserve rejected IDs in explicit failure fields or fail with an explicit partial-cleanup result.

Additional Info
Identified independently by 3/3 code-review agents.

Fix in Builder

// template we can't touch (missing access) is left dangling rather than
// retried here — both get-design-template and list-design-templates
// already mask a dangling designSystemId by resolving it back to null.
const [designResults, templateResults] = await Promise.all([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Bound per-resource access lookups during deletion

The action starts one resolveAccess call per linked design and template concurrently, and each lookup can load the full resource plus share rows. A widely reused system therefore creates an unbounded N+1 query burst during deletion, risking connection-pool and request-memory pressure. Use a scoped bulk access query or bounded concurrency with an access-only projection.

Additional Info
Identified by 1/3 review agents; included because the unbounded fan-out is directly introduced by this deletion path.

Fix in Builder

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 2 potential issues 🟡

Review Details

Code Review Summary

This incremental review covers the expanded deletion implementation in PR #4695. The update now limits unlinking to designs/templates the caller can edit, promotes a replacement default, reports skipped resources, and hardens Slides token merging against wrong runtime types. The core authorization alignment remains sound, and targeted action/UI-data tests were reported passing. This remains high risk because the action deletes shared resources and now returns cleanup metadata.

New Findings

  • 🟡 Medium: The action returns IDs of inaccessible linked designs/templates in designsSkippedForAccess and templatesSkippedForAccess, allowing a shared design-system admin to enumerate private resource IDs.
  • 🟡 Medium: Default promotion relies on access.resource.isDefault captured before the transaction. Concurrent deletion/promotion can make that value stale and leave the owner with no default system.

The previously reported issues about revocation races, deleted-system scope, reassigned links, swallowed unlink failures, and unbounded access lookups remain unresolved and were not reposted. The earlier malformed-token issue appears addressed by the new runtime-type fallback test and implementation, but its prior review comment was not cleared because it is not included in the currently listed unresolved comments.

🧪 Browser testing: Will run after this review (PR touches UI code)

return {
id,
deleted: true,
...(designsSkippedForAccess.length > 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Deletion response exposes IDs of inaccessible linked resources

The action selects every linked design/template, then returns inaccessible resource IDs in designsSkippedForAccess and templatesSkippedForAccess. A user with admin access only to the shared design system can enumerate private design/template IDs owned by others. Return aggregate counts or only identifiers the caller is authorized to read.

Additional Info
New issue identified in one focused review; no existing comment covers this response-data exposure.

Fix in Builder

.delete(schema.designSystems)
.where(eq(schema.designSystems.id, id));

if (access.resource.isDefault) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent deletes can leave an owner with no default system

The promotion decision uses access.resource.isDefault captured before the transaction. In a concurrent sequence, another delete can promote this system after that access result is read, and this delete can then remove it while skipping the promotion branch because the value was stale. Re-read/lock the current row and owner scope inside the transaction before deciding whether to promote, and add a concurrent-delete regression test.

Additional Info
New issue identified in one focused review; distinct from the existing comment about using the deleter's org scope.

Fix in Builder

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 2 potential issues 🟡

Review Details

Code Review Summary

This incremental review evaluated the expanded deletion cleanup at the latest PR head. The implementation now separates design-system authorization from access to linked designs/templates, reports skipped resources, promotes a replacement default, and normalizes wrong-typed Slides token leaves. The seven previously open deletion comments remain applicable and were not reposted. This remains high risk because it changes authorization, deletion ordering, and cross-resource cleanup.

New Findings

  • 🟡 Medium: Linked design/template discovery performs full-table scans because the new design_system_id lookups have no supporting indexes in the Design schema/migrations. This adds a request-path performance regression that scales with all workspace resources.
  • 🟡 Medium: Linked-resource IDs are snapshotted before deletion. A concurrent writer that passed its design-system access check before the delete can commit a new reference after the snapshot and after the system row is removed, leaving a dangling link that cleanup never visits. This is distinct from the existing open comment about clearing a reassigned link during cleanup.

The access-boundary changes and malformed-token normalization are directionally sound, and focused action/hook tests pass under their workspace configurations. I did not post the alias-resolution observation because it is dependent on invoking the test from the wrong repository-level configuration; the Slides workspace runner passes it.

🧪 Browser testing: Will run after this review (PR touches UI code)

.update(schema.designs)
.set({ designSystemId: null, updatedAt: new Date().toISOString() })
.where(eq(schema.designs.designSystemId, id));
const [linkedDesignIds, linkedTemplateIds] = await Promise.all([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cleanup misses links attached after the pre-delete snapshot

The linked design/template IDs are read before the deletion transaction. A writer that passed its design-system access check before this delete can still commit a new designSystemId reference after the snapshot and after the system row is removed, so that link is never visited by cleanup and remains dangling. Serialize deletion with link writes or add a post-delete conditional cleanup that covers links created during the race.

Additional Info
New issue identified independently during the latest incremental review; distinct from the existing reassigned-link cleanup comment.

Fix in Builder

Comment on lines +76 to +85
const [linkedDesignIds, linkedTemplateIds] = await Promise.all([
db
.select({ id: schema.designs.id })
.from(schema.designs)
.where(eq(schema.designs.designSystemId, id))
.then((rows) => rows.map((row) => row.id)),
db
.select({ id: schema.designTemplates.id })
.from(schema.designTemplates)
.where(eq(schema.designTemplates.designSystemId, id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Linked-resource discovery performs full-table scans

The new pre-delete lookups filter designs and designTemplates by design_system_id, but the Design schema/migrations provide no supporting indexes for these columns. Every deletion now scans both resource tables, including systems with no links, so latency grows with the entire workspace. Add additive indexes for both link columns.

Additional Info
New issue identified during the latest incremental review; verified against the Design schema/migration definitions.

Fix in Builder

@github-actions

Copy link
Copy Markdown
Contributor

design preview: Open preview

Built by GitHub Actions from f2c77251ac85ce8a75a50fcc6d254e48a8c578cb; Netlify is serving the uploaded artifact.

@github-actions

Copy link
Copy Markdown
Contributor

slides preview: Open preview

Built by GitHub Actions from f2c77251ac85ce8a75a50fcc6d254e48a8c578cb; Netlify is serving the uploaded artifact.

@github-actions

Copy link
Copy Markdown
Contributor

clips preview: Open preview

Built by GitHub Actions from f2c77251ac85ce8a75a50fcc6d254e48a8c578cb; Netlify is serving the uploaded artifact.

@github-actions
github-actions Bot temporarily deployed to pr-4695-design September 14, 2026 14:48 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-4695-slides September 14, 2026 14:48 Destroyed

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes — no new findings

Review Details

Code Review Summary

This incremental review rechecked the latest PR head with randomized file ordering and focused on authorization, destructive cleanup, concurrency, default promotion, and test behavior. The diff is unchanged in the substantive deletion paths from the previous review cycle. The nine existing deletion comments remain unresolved and were not reposted. No additional high-confidence defects were found beyond those open issues. The malformed-token handling remains covered by runtime type normalization and focused tests. This remains high risk because the PR changes authorization and cross-resource deletion behavior.

Configured focused tests reported by the agents passed for the changed Design and Slides suites. One agent reproduced a repository-root Vitest alias failure for the Slides page-data test, while the Slides workspace runner passes; this was treated as a test-runner context issue rather than a new PR defect.

🧪 Browser testing: Will run after this review (PR touches UI code)

@liamdebeasi
liamdebeasi merged commit a07ebe3 into main Sep 14, 2026
51 checks passed
@liamdebeasi
liamdebeasi deleted the ai_main_fad3bc5b90f940509045 branch September 14, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants